3DS MAX as 64K intro editor

By kraviz

Introduction

Well, there's always a need to make a kind of introduction. I'm not like getting into scene history and its evolution. You all know what's going on today. Seems like we are in the Tools Era. My statement is not a starting point for discussion. You might have your own opinion and develop demos the way you like it. I'm only trying to focus on the main subject. Those of you who tried to develop some kind of editor (which someone else will be able to use) know it's not that easy. You have to spend a lot of time working on things which are not directly concerned with the demo itself and some development steps might be too boring. One of the possible solutions is to use some of the existing tools. I'm not talking about WZ/FR, ADDICT/Conspiracy or Demopaja/Moppi. If you are a coder you would surely like to create something yourself, concentrating on the engine and some aspects of the demo, but not an editor itself. Quite a lot of commercial tools are extensible, allowing usage of plug-ins. Creating your custom exporter/importer plug-in might be quite a flexible solution. In this article I'll go in for some details about 3D Studio MAX exporter development. Also need to mention, I'm going to concentrate on issues concerning 64K intros. I assume you already know some basic things about 3DS MAX plug-in development, have MAX SDK installed (otherwise read Random's article in previous HUGI or take a look at the references) and know the fundamentals of computer graphics (I am not going to explain what matrix or quaternion is). The same goes for MAX basics (you should at least understand what MAX's Geometry Pipeline System is). As for myself I'm using 3DS MAX 4, thus some information may be outdated for newer versions.

Disclaimer

The article might contain some mistakes, it is only based on my experience during work and experiments (while making "Another Day of Lil Drolley"). I'm not a plug-in development expert and I still do have plenty of questions without an answer.

Requirements

Let's formulate what we'd like to get from MAX:

1. Models 
   1.1 Type of primitive
       1.1.1 Original primitive and its parameters 
             (e.g. radius, segment count, smoothing)
       1.1.2 Clone (e.g. 10 boxes cloned from 1 original primitive)
   1.2 World coordinates, orientation (rotation angles), scale factors
   1.3 Modifier list (bend, taper etc)
   NB We also want to find clones of modified primitives
   1.4 Model hierarchy (e.g. car wheels linked to platform)
   1.5 Material
       1.5.1 Bitmap name(s)
       1.5.2 Texture parameters (tiling, alpha states, lighting etc)
2. Cameras
   2.1 Parameters (FOV, clip planes etc)
   2.2 World parameters (position, orientation)
3. Lights
   3.1 Parameters (color, type etc)
   3.2 World parameters (position, orientation)
4. Animations
   4.1 Objects (moving , rotation, scaling)
   4.2 Cameras (moving, rotation)
   4.3 Lights (moving, rotation)

Well, some of you will need more, others less - it's up to you to decide. Let's start.

Primitive vulgaris

Let's assume you've already got access to current node (INode) in your callback procedure. Some useful procs of INode:

TSTR GetName() - The name says it all. Pretty useful while debugging (I don't think you'd like to store the names in your data file).

BTW: Giving names to nodes (or just some of the most important ones, e.g. used for cloning) is a good habit - don't be lazy.

ULONG GetHandle() - Returns 3DMAX ID of current node.

Object* GetObjectRef() - Returns pointer to Object which current node is referencing (see below):

Object's functions (some functions are derived from other classes, but we shouldn't care about it at the moment):

ClassID ClassID() - Returns ClassID of object, which helps determine the type of the primitive (e.g. box).

SClassID SuperClassID() - Returns SClass_ID of the current object. This method is pretty useful because some object classes are derived from basic classes and it's much easier to define their "generic" type with this function. But probably you'll prefer to use the next method.

int CanConvertToType(Class_ID) - returns non zero if the current object can be converted to some type.

Example:

if (obj->CanConvertToType(Class_ID(BOXOBJ_CLASS_ID,0)))say("Supa kub!");

I prefer to make a function which retrieves the object and returns converted ID (I have only 1 byte per ID), or error code if this kind of primitive is not supported. In case your object was modified (bent, tapered etc) or cloned (more details in next chapter), its SClassID will be something like GEN_DERIVOB_CLASS_ID. Then you'll be able to get the original object (the one you see down the pipeline) with:

Object *FindBaseObject() - returns the base object in the pipeline.

The type of the returned Object can be checked with the earlier mentioned: CanConvertToType(Class_ID), which this time will return the true type you were expecting.

After we've managed to determine the type of the object, there's a need to get its parameters. MAX allows using quite a flexible approach. Each object has a list of parameters which you can retrieve (this is not only for geometry, but also for modifiers and space warps). Main functions are (defined virtual in class BaseObject - one of the base classes for Object):

IParamArray *GetParamBlock() - returns pointer to an object which stores the array of different object's parameters (they may be of different types).

IParamArray's most important function is:

BOOL GetValue(int i, TimeValue t, SOME_TYPE &v, Interval &ivalid) - retrieves the value of parameter with index i at time t. SOME_TYPE might be float, int or Point3; v will be set to the parameter's value. If the value was successfully retrieved TRUE will be returned.

int GetParamBlockIndex(int id) - returns the index of the id-th parameter. You should use this function to retrieve the index of the parameter in the array the value of which you wish to know (actually you can directly use values from MAXSDK\Include\istdplug.h, but in case SDK constants/macros will be changed, expect problems).

Example:

...

// is it a sphere?
if (object->CanConvertToType(Class_ID(SPHERE_CLASS_ID,0)))
{
// get pointer to parameters of current object:
IParamArray* params = object->GetParamBlock();
// I strongly recommend to always check returned values, it's so easy  
// to hang and so hard to wait for MAX to load again:
if(!params)alertExit("NULL params!!!");

// get indices of basic parameters :
int radius_i	=	object->GetParamBlockIndex(SPHERE_RADIUS);
int smooth_i	=	obj->GetParamBlockIndex(SPHERE_SMOOTH);
int segs_i	=	obj->GetParamBlockIndex(SPHERE_SEGS);
int hemi_i	=	obj->GetParamBlockIndex(SPHERE_HEMI);
...
// variables for parameters:
float	radius,hemi;
int	segments,smooth;
...
// retrieve the values:
params->GetValue(radius_i,0,radius,interval);
params->GetValue(smoth_i,0,smooth,interval);
params->GetValue(segs_i,0,segments,interval);
params->GetValue(hemi_i,0,hemi,interval);
...
}// if (object->CanConvertTo(Class_ID(SPHERE_CLASS_ID,0)))

NB I paid your attention to checking the returned values not by chance. We all are pretty lazy and hope things will work fine, but when working with MAX always keep thinking about "defensive programming" (in a nutshell: never make any dangerous assumptions). Because if anything goes wrong the probability to crash MAX is pretty high (e.g. I always get NULL trying to access UVModifier's parameters). You'll spend much more time reloading MAX than typing "redundant" lines of code.

Ok, back to INode's methods again:

Matrix3 GetNodeTM(TimeValue t, Interval* valid=NULL) - returns node's matrix at specified time. It's a 4x3 matrix (translations in last row). We'll use it to get information about world parameters (don't be afraid you won't have to decompose it yourself). Here are some useful Matrix3's procs:

Point3 GetTrans() - Returns translation component (cool HaXXoRs should forget about this method).

Parity() - Returns true if there was a negative scaling on one of the axes (e.g. mirror). You'll have to check it and flip normals (in case you're exporting a mesh) or change Euler angles.

Invert() - inverts the matrix (wow!).

GetYawPitchRoll(float *yaw, float *pitch, float *roll) - gets Euler angles out of matrix. It might return NaN (not a number) because of some rounding errors. This one fixed the problem:

// by Lanier David
void fixedGetYawPitchRoll(Matrix3* m, float *Yaw, 
                          float *Pitch, float *Roll) 
{
	if(!m || !Yaw || !Pitch || !Roll) return;

	const Point4& Col0 = m->GetColumn(0);
	const Point4& Col1 = m->GetColumn(1);
	const Point4& Col2 = m->GetColumn(2);

	double sh, ch, sr, cr;
	double sp = -Col1[2];
	double temp = 1.0 - sp * sp;
	if (temp < 0.0) temp = 0.0; 
	//clamp it due to rounding errors
	double cp = sqrt(temp);
	if (cp < 1E-6) 
	{
		sh = -Col2[0];
		ch = Col0[0];
		sr = 0.0;
		cr = 1.0;
	}
	else 
	{
		sh = Col0[2] / cp;
		ch = Col2[2] / cp;
		sr = Col1[0] / cp;
		cr = Col1[1] / cp;
	}
	
	*Roll = (float)atan2(sr, cr);
	*Pitch = (float)atan2(sp, cp);
	*Yaw = (float)atan2(sh, ch);
}

Hope you prefer to use quaternions, otherwise you're going to have quite a lot of problems with Euler angles.

That's a point we came to a need of matrix decomposition to retrieve scale factor and another way to get angles. You'll have to use this structure (stored in maxsdk\include\decomp.h):

typedef struct {
	Point3 t;	/* Translation components */
	Quat q;	/* Essential rotation	  */
	Quat u;	/* Stretch rotation	  */
	Point3 k;	/* Stretch factors	  */
	float f;	/* Sign of determinant - 
                           remember Matrix3::parity method? */
	} AffineParts;

decomp_affine(Matrix3 A, AffineParts *parts) - will fill all the above parts with needed info from provided matrix.

If you still want to play with Euler try out this function (maxsdk\include\quat.h):

QuatToEuler(Quat &q, float *ang) - It will generate your angles from AffineParts::q.

This should be enough for quite a lot of cases.

Clone'em all

At the moment we are already able to retrieve main information about the scene from MAX. Even though storing primitive parameters and world coordinates doesn't need loads of bytes (compared to storing vertex and face information), cloning can be even more efficient (I'm not even talking about the modeling flexibility, when you can see a lot of monotonous objects modification after changing parameter of only one object in the scene). The problem is MAX's referencing system was designed for geometry pipeline. This means it's easy to access referenced geometry (vertices/faces), but hard to determine scene node which was cloned. As you have probably noticed FindBaseObject() returns Object but not INode, which could be used to retrieve ancestor's ID. The simplest solution I came to is storing a list of all previous objects (their main parameters). If we meet a derived object we can retrieve its BaseObject and get all the needed parameters. Afterwards there will only be a need to find the same primitive with the same parameters in our object list (clone will only store ancestor's ID which will provide all the needed information). Then we can also check whether there's a need to store all the other parameters of cloned object (angles, scales, texture, animation etc), as in most cases this can also save some bytes. But meeting derived object doesn't actually mean it's some clone. It will also be of type GEN_DERIVOB_CLASS_ID in case it was modified (more in next chapter), thus if you didn't find any object with the same parameters in your object list you can add it as original object with all the parameters and other information.

Hope next piece of code makes it a bit clearer:

// is it potential clone?
if( obj->SuperClassID() == GEN_DERIVOB_CLASS_ID )
{
	// get base object (ancestor):
	Object* original_obj = obj->FindBaseObject();
// set your own ID of this primitive
int	my_prim_id	=	setPrimitiveID(original_obj);
...
// this function will retrieve all the object parameters depending on its 
// type, will try to find the same object in the list and if possible add
// it to the list with minimum set of needed parameters 
seekAndSaveObject(my_prim_id, original_obj);
...
}// if( obj->SuperClassID() == GEN_DERIVOB_CLASS_ID )
...
// the function itself
void CSceneSaver::seekAndSaveObject(const int prim_id, Object*	object)
{		
	if(!object)alarmExit("NULL object provided!);
switch(prim_id)
{
	// the constants are your own (as I've already mentioned)
	case(INTRO_BOX_ID):
{
	...
}	
case(INTRO_SPHERE_ID):
{
// take a look at GetParamBlockIndex example
int radius_i = object->GetParamBlockIndex(SPHERE_RADIUS);
...
// you are free to use your own base class with overloaded == operator 
// to find the same object
// CIntroSphere is derived from CIntroObject, having world coords, 
// texture id etc)
CIntroSphere*	sphere	=	new CIntroSphere(radius,segments,smooth,hemi);
// this will search for the sphere with the same ID and call 
// CIntroObject::setCloneAncestor() if it found 
// the "ancestor" sphere
my_scene->tryToAdd(sphere);
}
...
}// switch(prim_id)
...
}// seekAndSaveObject(int ID , )

I'm not talking a lot about the hierarchy of my classes (like CIntroSphere, CIntroObject) and their methods not to make the whole explanation too complex. I've only made an illustration of the way it can be done.

If you're familiar with STL you can use list or even some hash_set for the purpose of storage and search.

Modifiers

Working with modifiers is a bit tougher. You know that each modifier is added on the top of the stack. Thus you'll have to get each level of stack and retrieve its parameters. You'll need IDerivedObject's method (MAXSDK\Include\stackmod.h):

Object *GetObjRef() - returns pointer to previous object in the stack. You start moving from the top of the stack and will finally reach the base object (some kind of primitive).

int NumModifiers() - returns number of modifiers applied to object on current stack level.

Modifier *GetModifier(int index) - returns pointer to needed modifier.

Modifier has not got a lot of interesting useful procs for us (except TSTR GetName() - only worthwhile when debugging). We are only going to use its provided parameters (still remember GetParamBlock() from BaseObject, which is one of the base classes for Modifier). Here's another example:

// gets all stack info from obj
void	CSceneSaver::getStack(Object*	obj)
{
if( obj->SuperClassID() == GEN_DERIVOB_CLASS_ID )
	{
		// cast to class providing more functionality
		IDerivedObject* d_obj = (IDerivedObject*)obj;
// get previous object in the stack
		Object* obj_ref = d_obj->GetObjRef();
		// get next modifier (till base obj is reached)
// yep, that is recursion
		getStack(obj_ref);
		// now get all the modifiers of current object
		getCurrentStackLevel(d_obj);		
	}
}// void	CSceneSaver::getStack(Object*	obj)

// gets modifiers from current stack level
void	CSceneSaver::getCurrentStackLevel(IDerivedObject* d_obj)
{
	// go through all applied modifiers
	for(int i=0;i<d_obj->NumModifiers();i++)
	{
		Modifier* modifier = d_obj->GetModifier(i);
IParamArray* params = modifier->GetParamBlock();
		if(!params)
{
	log("Time 2 die (%s)",modifier->GetName());
return;
}
// setting your own ID
		int	mod_id	=	setModifierID(modifier);
		switch (mod_id)
		{
			case MY_MOD_BEND_ID:
				…		
			case MY_MOD_TAPER_ID:
	{
		// retrieve parameters as usually
		// amount index
		int am_i = modifier->GetParamBlockIndex(TAPER_AMT);
		// taper mod. Angles indices
		int f_i = modifier->GetParamBlockIndex(TAPER_FROM);
		int t_i = modifier->GetParamBlockIndex(TAPER_TO);
...

	params->GetValue(am_i,0,taper.amount,interval);
	params->GetValue(f_i,0,taper.from,interval);
	params->GetValue(t_i,0,taper.to,interval);
...
			break;
}
		...
		}
	}//for(int i =0;i<d_obj->NumModifiers();i++)
};//void CSceneSaver::getCurrentStackLevel(IDerivedObject* d_obj)

I put modifiers to a special list while modified objects store the indices of modifiers in this list. This also allows modification of cloned objects still saving bytes as you can find a couple of objects with the same generation parameters and choose an ancestor depending on the maximum number of matching modifier indices. When you've found an ancestor of a current object you can save additional modifier indices, which were only applied to the cloned object. Well, you'll also have to spend a lot of time (actually, due to lack of time and laziness I still did not) writing just the same modifiers as they are present in MAX. Another important thing is the order of applying them.

BTW I always get NULL after modifier->GetParamBlock() for UVWMAPOSM_CLASS_ID. Anyone knows why?

"Look, mummy, I can use hierarchy!"

If you are going to do something more interesting than static fly-by you will surely need hierarchy.

First of all you should change your MAX's callback function a bit. You should already have your class derived from ITreeEnumProc with method:

int callback( INode *node ) - this one is called for each node in the scene, so you can handle it the way you need.

But when we work with hierarchy we don't need all the nodes. Just parent (or simple standalone) nodes are interesting, as we can get all child nodes from them. Thus we should tell MAX not to call our function for child nodes. To do so callback( INode *node ) should simply return TREE_IGNORECHILDREN instead of TREE_CONTINUE. Some more methods from INode we'll use:

int NumberOfChildren() - total number of children.

INode* GetChildNode(int i) - retruns i-th child node.

Matrix3 GetParentTM(TimeValue t) - this one can be used by children to retrieve their parent's matrix.

The problem is each child node's world parameters need to be recalculated to the parent's local system. Child nodes need to retrieve the parent's matrix, inverse it and multiply their own matrix by the inverted parent's matrix:

...
// get current matrix
Matrix3	matrix = node->GetNodeTM(0);
// we know this node is a child
INode* parent_node = node->GetParentNode();
// still better check whether it is valid parent
if(parent_node && !parent_node ->IsRootNode())
{
	// get parent's matrix
	Matrix3	pm	=	node->GetParentTM(0);
	// inverse it
	pm.Invert();
// calculate local matrix
	Matrix3	local_matrix	=	matrix*pm;
// you can now decompose it and do whatever you want to
...

}

In case you move local centers (pivot points) of your objects for animation (e.g. leg presented as non-uniform scaled sphere shouldn't be rotated around its default center) there's another step to be done. Use the following function of INode to determine the so-called offset translation:

Point3 GetObjOffsetPos() - returns offset from default center.

Materials

You shouldn't experience serious problems while trying to export materials. First of all call INode's:

Mtl* GetMtl() - returns current Node's assigned material (or NULL if none was assigned).

Check its type:

        Mtl*    material        =       node->GetMtl();
// no material might have been assigned
	if(!material)
{
	say("I'm a material girl...");
	return ;
}
// usual standard material
if( material->ClassID() != Class_ID(DMTL_CLASS_ID, 0) )
{
	// cast it this way to access some extra functionality
	// StdMat is derived from Mtl
StdMat* std_mat = (StdMat*)material; 
...

}

You can also play with other types of materials but standard is pretty powerful. What we will need from StdMat (MAXSDK\Include\stdmat.h):

void Update(TimeValue t, Interval& valid) - updates material at specified time (actually it's declared by MtlBase). If you don't call this method while exporting material, some of its parameters (like type of shading, wrapping etc) will be set to 0 even if their real values differ from 0.

BOOl MapEnabled(int id) - returns TRUE if subtexture of specified type is used. id can be ID_DI (diffuse), ID_OP (opacity), ID_BU (bumpmap), ID_SP (specular) and some more (take a look at SDK docs or MAXSDK\Include\stdmat.h). E.g. you can use it to check whether some opacity map was used.

Texmap* GetSubTexmap(int i) - returns subtexture of specified type. More about subtextures below.

float GetTexmapAmt(int imap, TimeValue t) - returns amount of used subtexture (like i in previous function) from to 0 to 1 (that is percentage against each subtexture you set while creating material).

int GetShading() - returns type of shading (e.g. SHADE_CONST, SHADE_PHONG, SHADE_METAL, SHADE_BLINN). Well, as I was creating intro for fixed pipeline card (Gouraud shading) I've used this parameter to set some other things.

BOOL GetTwoSided() - returns TRUE if material is two sided (like you'll have to turn polygon culling off).

int GetTransparencyType() - used to define type of transparency for opaque objects. Returns TRANSP_SUBTRACTIVE, TRANSP_ADDITIVE, TRANSP_FILTER.

Subtextures don't have to be bitmaps so you have to check them:

Texmap *diffuse_map = mat->GetSubTexmap(ID_DI);
if(!diffuse_map)return;
// usual bitmap used for texture
if(diffuse_map->ClassID() == Class_ID(BMTEX_CLASS_ID, 0) )
{
	// convert to object which has some more useful methods
	BitmapTex *bitmap = (BitmapTex*)diffuse_map;
	...
}

Let's take a more accurate look at BitmapTex methods:

TCHAR GetMapName() - returns name of bitmap used for current subtexture. Be careful, because MAX sometimes saves absolute paths and it's more reasonable to manipulate the returned string (i.e. delete the absolute path).

StdUVGen* GetUVGen() - returns pointer to object providing more information about generation of texture coordinates.

How about StdUVGen methods? Yep, here we go:

int GetCoordMapping(int) - returns type of mapping used for current bitmap (e.g. UVMAP_EXPLICIT, UVMAP_SPHERE_ENV, UVMAP_SCREEN_ENV). Latest investigations tell sceners still love fake environment mapping with random noise texture.

float GetUScl(TimeValue t) - returns scaling on U-axis at specified time.

float GetVScl(TimeValue t) - returns scaling on V-axis at specified time.

int GetTextureTiling() - returns type of clamping (U_MIRROR, U_WRAP).

Think you've got the way to groove. Back to the intro again. I prefer to create special material library. The scene object only stores the index of material it's been assigned. Thus the first step is to load a library and register all its materials. Take a look at the code:

        // ip is a pointer to global interface provided 
	// as parameter for SceneExport::DoExport(...) function
	if (!ip->LoadMaterialLib(your_lib_name)) 
	{
		say("Couldn't load material library! (%s)",
		    your_lib_name);
		return ;
	}
	// get currently loaded material library
	MtlBaseLib mlib = ip->GetMaterialLibrary();
If(!mlib)
{
	say("Are you still alive?");
	return;
}

	// travel through all materials
	for (int i=0; i<mlib.Count(); i++)  
	{
		Mtl*	mat	=	(Mtl*)mlib[i];
// cast it to derived class
StdMat* std_mat = (StdMat2 *)mlib[j];
// update to retrieve real values
		std_mat->Update(0,interval);
// let's play with diffuse
		if(Texmap *tex_map = mat->GetSubTexmap(ID_DI))
		{		
			// get basic params:
int transp		=	std_mat->GetTransparencyType();
int two_side		=	std_mat->GetTwoSided();
BOOL	opaque		=	std_mat->MapEnabled(ID_OP);
int	shade		=	std_mat->GetShading();

			// is it a bitmap?
			if (tex_map->ClassID() == 
                            Class_ID(BMTEX_CLASS_ID, 0)) 
			{
				// another cast to bitmap
				BitmapTex *bmt = (BitmapTex*) tmap;

				TCHAR	name =
                                  bmt->GetMapName();
				// texture generation params:
				StdUVGen *uv = 
                                  bmt->GetUVGen();

				float	utile =	uv->GetUScl(0);
				float	vtile =	uv->GetVScl(0);
				int mapping   =
                                  uv->GetCoordMapping(tmp);
}// if (tex_map->ClassID() == Class_ID(BMTEX_CLASS_ID, 0)) 
}//if(Texmap *tex_map = mat->GetSubTexmap(ID_DI))

// add material to my library
		my_materials.addMaterial(name, transp, two_sided, 
                     shade, utile, vtile, mapping);
		...
}// for (int i=0; i<mlib.Count(); i++)

The idea behind is pretty obvious. We load MAX's library (which you create for your purpose) and save all its materials in our own library (addMaterial(...) creates an object of class CMaterial). Later we can retrieve all the same parameters from objects in the scene and find the index of the same material in our material library. Thus the object will only store this index instead of the parameters of all materials (I suppose 1 byte, i.e. 256 unique materials, is pretty enough for one intro).

Some words for cameramen and lighters

I know you are already tired. Let's speed up.

You can check whether object is camera or light:

// usual camera which can be rotated on all the axes
obj->CanConvertToType(Class_ID(SIMPLE_CAM_CLASS_ID,0)
// look at cam (it has 2 points to be controlled)
obj->CanConvertToType(Class_ID(LOOKAT_CAM_CLASS_ID,0))
// directional lighting 
obj->CanConvertToType(Class_ID(DIR_LIGHT_CLASS_ID,0))
// spot light
obj->CanConvertToType(Class_ID(SPOT_LIGHT_CLASS_ID,0))

Try to convert to GenCamera (MAXSDK\Include\gencam.h) in case you've determined it is camera and use the following methods:

int Type() - camera's type (FREE_CAMERA, TARGETED_CAMERA, PARALLEL_CAMERA).

float GetFOV(TimeValue t, Interval& valid = Interval(0,0)) - returns FOV at specified time.

float GetClipDist(TimeValue t, int which, Interval &valid=Interval(0,0)) - returns clip distance (near clip if which is equal to CAM_HITHER_CLIP or far clip if which is equal to CAM_YON_CLIP).

Moving to GenLight:

Point3 GetRGBColor(TimeValue t, Interval &valid = Interval(0,0)) - return RGB triplet of light's color.

int GetShadow() - if returns 0 => this light can cast a shadow.

float GetAtten(TimeValue t, int which, Interval& valid = Interval(0,0)) - returns the specified attenuation range distance at the time passed (which can be LIGHT_ATTEN_START, LIGHT_ATTEN_END).

That's it. Hey, and how about orientation and world position? It's all just the same as for usual scene objects. Recollect it or take a look at INode::GetNodeTM(...) again.

"Animate!" (from an almost forgotten 4k)

Time to pass to one of the most interesting parts. First of all we need to determine FPS and animation range defined in MAX. There are a couple of global functions for that purpose:

int GetFrameRate() - returns the number of frames per second (you can set it yourself, but usually classical 25 are all you need).

int GetTicksPerFrame() - returns the number of ticks per frame (usually 160 t/s).

You can get further info with methods of class Interface (ip provided to export callback function):

Interval GetAnimRange() - returns an interval which helps to determine the last frame of the animation in the scene (use Interval's Start()/End() methods to get the real values). Be careful: the returned value is represented in ticks.

MAX's animation system is based on controllers, which manipulate some parameters in time. First of all we need to retrieve the node's controller (MAXSDK\Include\control.h):

Control* GetTMController() - returns the node's controller.

Some interesting methods of Control:

int NumKeys() - returns number of keyframes for current animation.

TimeValue GetKeyTime(int index) - returns time of index-th key in ticks.

Control *GetPositionController() - returns pointer to controller responsible for position animation.

Control *GetRotationController() - returns pointer to controller responsible for rotation animation.

Control *GetScaleController() - returns pointer to controller responsible for scale animation.

int GetORT(int type) - returns out of range type (ORT). It determines the way you have to extrapolate values when the time is out of defined range (e.g. linear extrapolation depending on last 2 points, looping, constant value). Type can be ORT_AFTER (values after last key frame) or ORT_BEFORE (values before first key frame). The returned value is one of the following: ORT_CONSTANT, ORT_CYCLE, ORT_LOOP, ORT_OSCILLATE, ORT_LINEAR, ORT_IDENTITY, ORT_RELATIVE_REPEAT. If you've ever set ORT values in MAX you should understand the meaning of each value (iconic pictures describe it better).

It is also important to determine the type of used interpolation between keys. This can be done with a familiar function:

Class_ID ClassID() - returned instance should be compared to one of the following values (depending on type of controller):

LININTERP_POSITION_CLASS_ID - position keys with linear interpolation.
HYBRIDINTERP_POSITION_CLASS_ID - position keys with Bezier interpolation.
LININTERP_ROTATION_CLASS_ID - rotation keys with linear interpolation.
HYBRIDINTERP_ROTATION_CLASS_ID - rotation keys with Bezier interpolation.
LININTERP_SCALE_CLASS_ID - scaling keys with linear interpolation.
HYBRIDINTERP_SCALE_CLASS_ID - scaling keys with Bezier interpolation.

When you managed to retrieve the controllers for each type of animation, it's time to get the values of the key frames. First of all you'll have to use the following casting macros (MAXSDK\Include\animtbl.h):

GetKeyControlInterface(anim) - anim is an instance of class Control (it is the controller which keys you wish to access), returned value is of type IKeyControl.

The main method of IKeyControl you will use is:

void GetKey(int i,IKey *key) - which fills the key's values with data from i-th keyframe. Actually there are 2 main classes derived from IKey the instances of which can be provided to the GetKey function depending on key frames type and its interpolation method (MAXSDK\Include\istdplug.h):

ILinPoint3Key, ILinScaleKey, IBezPoint3Key, IBezScaleKey, ILinQuatKey, IBezQuatKey - their main field is val of type Point3 (Quat for rotation).

As you've already guessed I'll show another example:

Control *c;
// retrieve number of frames per second
int	fps	=	GetFrameRate();
// & number of ticks per frame
int	tpf	=	GetTicksPerFrame();
...
// get current node's position controller
c  =  node->GetTMController()->GetPositionController();
// every node doesn't have to be animated
if(!c)return;
// get ORT values
int ort1	=	c->GetORT(ORT_BEFORE);
int ort2	=	c->GetORT(ORT_AFTER);
// how many keyframes were defined
int	num_keys	=	c->NumKeys();
if(!num_keys)return;
// retrieve keyframe interface
IKeyControl	ikeys	=	GetKeyControlInterface(c);	
// Bezier interpolation for position?
if (c->ClassID() == Class_ID(HYBRIDINTERP_POSITION_CLASS_ID, 0))
{
	for (int i = 0; i < num_keys; i++)
{
	// calculate frame number of the frame
	int frame = c->GetKeyTime (i)/tpf;		
IBezPoint3Key PosKey;
// retrieve the value
	ikeys->GetKey(i, &PosKey);
	// save it somewhere
	saveNewAnimationFrame(frame,PosKey.val.x, 
          PosKey.val.y, PosKey.val.z);
	
}// for (int i = 0; i < num_keys; i++)

}// if (c->ClassID() == 
 //Class_ID(HYBRIDINTERP_POSITION_CLASS_ID, 0))
// how about linear movement?
else if (c->ClassID() == 
          Class_ID(LININTERP_POSITION_CLASS_ID, 0))
{
	
for (int i = 0; i < num_keys; i++)
{
	int frame = c->GetKeyTime (i)/tpf;		
ILinPoint3Key PosKey;
	ikeys->GetKey(i, &PosKey);
	saveNewAnimationFrame(frame,PosKey.val.x, 
                         PosKey.val.y, PosKey.val.z);
	
}// for (int i = 0; i < num_keys; i++)

}// else if (c->ClassID() == 
 //Class_ID(LININTERP_POSITION_CLASS_ID, 0))
...

This technique also works for cameras and lights (any node if you still didn't get it). If you still don't know the way to interpolate 3D Studio tracks, read coding articles from HUGI SE#1, they might be pretty useful.

Ok, I assume that's enough for a first quick course. Next come some advice and less useful information.

"This error should have never happened" (from various Windows dialogs)

MAX hangs a lot. I've already written about it and always keep it in mind. Check all the returned values and react normally if things go wrong.

Another problem is reloading the plug-in each time you recompile it (because DLL is loaded by MAX and Windows won't let you change it). You can write your own plug-in consisting of 2 DLLs: The first one is loaded by MAX and second part is loaded by the first one every time you export the scene (the second one does the real export). Another way is to buy a Plugin Loader ($10 isn't that much for such a useful tool), which allows loading/unloading of any MAX plug-in at any time.

Summing it all up

Pros

- Decreasing of development time (time for tool + demo development time).

- Quite a lot of documentation.

Contras

- Quite a lot of documentation (sometimes too complicated to get it all quickly).

- API is mostly "visualization centered", complicating the issue of getting scene objects' links/relations. That's what I've mentioned in "Clone'em all part".

- Repetition - you'll have to make the things work exactly like they are in MAX and not the way you like. This is for default texture coordinates, default orientation, modification behavior, blah-blah-blah. Don't expect MAX sources to help you a lot, as they are very complex (you'll have to break through hundreds of lines responsible for UI control, MAX registration and other basic things which are not directly concerned with what you are looking for).

Further work

Whenever there are more pauses from real life following things need to be done: 1. Animate
1.1 Objects parameters
1.2 Material parameters
1.3 Some good way to feet skeletal animation (bones, weights, relations from Character Studio) in 64K.
2. Usage of G-buffer index for different post-processing scenarios
3. Shaders support

Final words

Ok time to finish it all up. Greets to lamers, muckings to elites.

PS You might ask, how could this Russian bastard be developing plug-ins for a software costing about 4000$? The answer is pretty obvious: I'm immensely rich!

References:

1. 3D Studio MAX 4.0 Software Development Kit.

2. Sparks Knowledge Base (http://sparks.discreet.com)

3. "From 3D Studio MAX to Direct 3D: Intro to Plugin Development" by Loic Baumann.
(http://www.gamasutra.com/features/19980220/baumann_01.htm)

4. HUGI SE #1 (www.hugi.scene.org)

5. Plugin Loader (http://soft.web-malina.com)

kraviz